const _ = require('lodash');
const Utils = require('./utils');
const sequelizeError = require('./errors');
const DataTypes = require('./data-types');
const BelongsTo = require('./associations/belongs-to');
const validator = require('./utils/validator-extras').validator;
const { promisify } = require('util');

/**
 * Instance Validator.
 *
 * @param {Instance} modelInstance The model instance.
 * @param {object} options A dictionary with options.
 *
 * @private
 */
class InstanceValidator {
  constructor(modelInstance, options) {
    options = {
      // assign defined and default options
      hooks: true,
      ...options
    };

    if (options.fields && !options.skip) {
      options.skip = _.difference(Object.keys(modelInstance.constructor.rawAttributes), options.fields);
    } else {
      options.skip = options.skip || [];
    }

    this.options = options;

    this.modelInstance = modelInstance;

    /**
     * Exposes a reference to validator.js. This allows you to add custom validations using `validator.extend`
     *
     * @name validator
     * @private
     */
    this.validator = validator;

    /**
     *  All errors will be stored here from the validations.
     *
     * @type {Array} Will contain keys that correspond to attributes which will
     *   be Arrays of Errors.
     * @private
     */
    this.errors = [];

    /**
     * @type {boolean} Indicates if validations are in progress
     * @private
     */
    this.inProgress = false;
  }

  /**
   * The main entry point for the Validation module, invoke to start the dance.
   *
   * @returns {Promise}
   * @private
   */
  async _validate() {
    if (this.inProgress) throw new Error('Validations already in progress.');

    this.inProgress = true;

    await Promise.all([this._perAttributeValidators(), this._customValidators()]);

    if (this.errors.length) {
      throw new sequelizeError.ValidationError(null, this.errors);
    }
  }

  /**
   * Invoke the Validation sequence and run validation hooks if defined
   *   - Before Validation Model Hooks
   *   - Validation
   *   - On validation success: After Validation Model Hooks
   *   - On validation failure: Validation Failed Model Hooks
   *
   * @returns {Promise}
   * @private
   */
  async validate() {
    return await (this.options.hooks ? this._validateAndRunHooks() : this._validate());
  }

  /**
   * Invoke the Validation sequence and run hooks
   *   - Before Validation Model Hooks
   *   - Validation
   *   - On validation success: After Validation Model Hooks
   *   - On validation failure: Validation Failed Model Hooks
   *
   * @returns {Promise}
   * @private
   */
  async _validateAndRunHooks() {
    const runHooks = this.modelInstance.constructor.runHooks.bind(this.modelInstance.constructor);
    await runHooks('beforeValidate', this.modelInstance, this.options);

    try {
      await this._validate();
    } catch (error) {
      const newError = await runHooks('validationFailed', this.modelInstance, this.options, error);
      throw newError || error;
    }

    await runHooks('afterValidate', this.modelInstance, this.options);
    return this.modelInstance;
  }

  /**
   * Will run all the validators defined per attribute (built-in validators and custom validators)
   *
   * @returns {Promise<Array>}
   * @private
   */
  async _perAttributeValidators() {
    // promisify all attribute invocations
    const validators = [];

    _.forIn(this.modelInstance.rawAttributes, (rawAttribute, field) => {
      if (this.options.skip.includes(field)) {
        return;
      }

      const value = this.modelInstance.dataValues[field];

      if (value instanceof Utils.SequelizeMethod) {
        return;
      }

      if (!rawAttribute._autoGenerated && !rawAttribute.autoIncrement) {
        // perform validations based on schema
        this._validateSchema(rawAttribute, field, value);
      }

      if (Object.prototype.hasOwnProperty.call(this.modelInstance.validators, field)) {
        validators.push(this._singleAttrValidate(value, field, rawAttribute.allowNull));
      }
    });

    return await Promise.all(validators);
  }

  /**
   * Will run all the custom validators defined in the model's options.
   *
   * @returns {Promise<Array>}
   * @private
   */
  async _customValidators() {
    const validators = [];
    _.each(this.modelInstance.constructor.options.validate, (validator, validatorType) => {
      if (this.options.skip.includes(validatorType)) {
        return;
      }

      const valprom = this._invokeCustomValidator(validator, validatorType)
        // errors are handled in settling, stub this
        .catch(() => {});

      validators.push(valprom);
    });

    return await Promise.all(validators);
  }

  /**
   * Validate a single attribute with all the defined built-in validators and custom validators.
   *
   * @private
   *
   * @param {*} value Anything.
   * @param {string} field The field name.
   * @param {boolean} allowNull Whether or not the schema allows null values
   *
   * @returns {Promise} A promise, will always resolve, auto populates error on this.error local object.
   */
  async _singleAttrValidate(value, field, allowNull) {
    // If value is null and allowNull is false, no validators should run (see #9143)
    if ((value === null || value === undefined) && !allowNull) {
      // The schema validator (_validateSchema) has already generated the validation error. Nothing to do here.
      return;
    }

    // Promisify each validator
    const validators = [];
    _.forIn(this.modelInstance.validators[field], (test, validatorType) => {
      if (validatorType === 'isUrl' || validatorType === 'isURL' || validatorType === 'isEmail') {
        // Preserve backwards compat. Validator.js now expects the second param to isURL and isEmail to be an object
        if (typeof test === 'object' && test !== null && test.msg) {
          test = {
            msg: test.msg
          };
        } else if (test === true) {
          test = {};
        }
      }

      // Custom validators should always run, except if value is null and allowNull is false (see #9143)
      if (typeof test === 'function') {
        validators.push(this._invokeCustomValidator(test, validatorType, true, value, field));
        return;
      }

      // If value is null, built-in validators should not run (only custom validators have to run) (see #9134).
      if (value === null || value === undefined) {
        return;
      }

      const validatorPromise = this._invokeBuiltinValidator(value, test, validatorType, field);
      // errors are handled in settling, stub this
      validatorPromise.catch(() => {});
      validators.push(validatorPromise);
    });

    return Promise.all(
      validators.map(validator =>
        validator.catch(rejection => {
          const isBuiltIn = !!rejection.validatorName;
          this._pushError(isBuiltIn, field, rejection, value, rejection.validatorName, rejection.validatorArgs);
        })
      )
    );
  }

  /**
   * Prepare and invoke a custom validator.
   *
   * @private
   *
   * @param {Function} validator The custom validator.
   * @param {string} validatorType the custom validator type (name).
   * @param {boolean} optAttrDefined Set to true if custom validator was defined from the attribute
   * @param {*} optValue value for attribute
   * @param {string} optField field for attribute
   *
   * @returns {Promise} A promise.
   */
  async _invokeCustomValidator(validator, validatorType, optAttrDefined, optValue, optField) {
    let isAsync = false;

    const validatorArity = validator.length;
    // check if validator is async and requires a callback
    let asyncArity = 1;
    let errorKey = validatorType;
    let invokeArgs;
    if (optAttrDefined) {
      asyncArity = 2;
      invokeArgs = optValue;
      errorKey = optField;
    }
    if (validatorArity === asyncArity) {
      isAsync = true;
    }

    if (isAsync) {
      try {
        if (optAttrDefined) {
          return await promisify(validator.bind(this.modelInstance, invokeArgs))();
        }
        return await promisify(validator.bind(this.modelInstance))();
      } catch (e) {
        return this._pushError(false, errorKey, e, optValue, validatorType);
      }
    }

    try {
      return await validator.call(this.modelInstance, invokeArgs);
    } catch (e) {
      return this._pushError(false, errorKey, e, optValue, validatorType);
    }
  }

  /**
   * Prepare and invoke a build-in validator.
   *
   * @private
   *
   * @param {*} value Anything.
   * @param {*} test The test case.
   * @param {string} validatorType One of known to Sequelize validators.
   * @param {string} field The field that is being validated
   *
   * @returns {object} An object with specific keys to invoke the validator.
   */
  async _invokeBuiltinValidator(value, test, validatorType, field) {
    // Cast value as string to pass new Validator.js string requirement
    const valueString = String(value);
    // check if Validator knows that kind of validation test
    if (typeof validator[validatorType] !== 'function') {
      throw new Error(`Invalid validator function: ${validatorType}`);
    }

    const validatorArgs = this._extractValidatorArgs(test, validatorType, field);

    if (!validator[validatorType](valueString, ...validatorArgs)) {
      throw Object.assign(new Error(test.msg || `Validation ${validatorType} on ${field} failed`), {
        validatorName: validatorType,
        validatorArgs
      });
    }
  }

  /**
   * Will extract arguments for the validator.
   *
   * @param {*} test The test case.
   * @param {string} validatorType One of known to Sequelize validators.
   * @param {string} field The field that is being validated.
   *
   * @private
   */
  _extractValidatorArgs(test, validatorType, field) {
    let validatorArgs = test.args || test;
    const isLocalizedValidator =
      typeof validatorArgs !== 'string' &&
      (validatorType === 'isAlpha' || validatorType === 'isAlphanumeric' || validatorType === 'isMobilePhone');

    if (!Array.isArray(validatorArgs)) {
      if (validatorType === 'isImmutable') {
        validatorArgs = [validatorArgs, field, this.modelInstance];
      } else if (isLocalizedValidator || validatorType === 'isIP') {
        validatorArgs = [];
      } else {
        validatorArgs = [validatorArgs];
      }
    } else {
      validatorArgs = validatorArgs.slice(0);
    }
    return validatorArgs;
  }

  /**
   * Will validate a single field against its schema definition (isnull).
   *
   * @param {object} rawAttribute As defined in the Schema.
   * @param {string} field The field name.
   * @param {*} value anything.
   *
   * @private
   */
  _validateSchema(rawAttribute, field, value) {
    if (rawAttribute.allowNull === false && (value === null || value === undefined)) {
      const association = Object.values(this.modelInstance.constructor.associations).find(
        association => association instanceof BelongsTo && association.foreignKey === rawAttribute.fieldName
      );
      if (!association || !this.modelInstance.get(association.associationAccessor)) {
        const validators = this.modelInstance.validators[field];
        const errMsg = _.get(
          validators,
          'notNull.msg',
          `${this.modelInstance.constructor.name}.${field} cannot be null`
        );

        this.errors.push(
          new sequelizeError.ValidationErrorItem(
            errMsg,
            'notNull Violation', // sequelizeError.ValidationErrorItem.Origins.CORE,
            field,
            value,
            this.modelInstance,
            'is_null'
          )
        );
      }
    }

    if (
      rawAttribute.type instanceof DataTypes.STRING ||
      rawAttribute.type instanceof DataTypes.TEXT ||
      rawAttribute.type instanceof DataTypes.CITEXT
    ) {
      if (
        Array.isArray(value) ||
        (_.isObject(value) && !(value instanceof Utils.SequelizeMethod) && !Buffer.isBuffer(value))
      ) {
        this.errors.push(
          new sequelizeError.ValidationErrorItem(
            `${field} cannot be an array or an object`,
            'string violation', // sequelizeError.ValidationErrorItem.Origins.CORE,
            field,
            value,
            this.modelInstance,
            'not_a_string'
          )
        );
      }
    }
  }

  /**
   * Signs all errors retaining the original.
   *
   * @param {boolean}       isBuiltin   - Determines if error is from builtin validator.
   * @param {string}        errorKey    - name of invalid attribute.
   * @param {Error|string}  rawError    - The original error.
   * @param {string|number} value       - The data that triggered the error.
   * @param {string}        fnName      - Name of the validator, if any
   * @param {Array}         fnArgs      - Arguments for the validator [function], if any
   *
   * @private
   */
  _pushError(isBuiltin, errorKey, rawError, value, fnName, fnArgs) {
    const message = rawError.message || rawError || 'Validation error';
    const error = new sequelizeError.ValidationErrorItem(
      message,
      'Validation error', // sequelizeError.ValidationErrorItem.Origins.FUNCTION,
      errorKey,
      value,
      this.modelInstance,
      fnName,
      isBuiltin ? fnName : undefined,
      isBuiltin ? fnArgs : undefined
    );

    error[InstanceValidator.RAW_KEY_NAME] = rawError;

    this.errors.push(error);
  }
}
/**
 * The error key for arguments as passed by custom validators
 *
 * @type {string}
 * @private
 */
InstanceValidator.RAW_KEY_NAME = 'original';

module.exports = InstanceValidator;
module.exports.InstanceValidator = InstanceValidator;
module.exports.default = InstanceValidator;
